feat(spec): the flow end node declares its outcome — refused with an interpolated message, and the run vocabulary gains refused - #15889
Conversation
…interpolated message (wip) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…edgers for the end-node outcome (wip) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…ow-end-node-refused-outcome
…rging origin/main Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
📓 Docs Drift CheckThis PR changes 1 package(s): 2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 129 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 368dff7b770155eef87119119ad13c1eb3695de5 && git checkout 368dff7b770155eef87119119ad13c1eb3695de5
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ea03c7c16fc27dcd489378faf8ba99abf9ae7124 87f6afcd20679030e4dbc55f2be52815b1482898 && git checkout -B drift-repro ea03c7c16fc27dcd489378faf8ba99abf9ae7124 && git merge --no-ff 87f6afcd20679030e4dbc55f2be52815b1482898
node scripts/docs-audit/affected-docs.mjs --json ea03c7c16fc27dcd489378faf8ba99abf9ae7124
|
…ow-end-node-refused-outcome
…nchor after merging origin/main (lap 2) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
Fixes #14945
Lane (1) of maintainer ruling 2′ (issue comment
5548735593, decision batch #42, 「13753 我让别人处理了,其他同意」): the spec contract. Lane (2) —service-automationhonouringrefusedat theendhandling and persisting the outcome / rendered message — is #15788 (Blocked-by: #14945, not addressed here). Lane (3) — the consoleFlowRunnerrendering Close-only — is objectstack-ai/objectui#7707 (not addressed here). This PR editspackages/speconly.What the contract now says
endnode config —EndConfigSchema(packages/spec/src/automation/builtin-node-config.zod.ts, exported from@objectstack/spec/automation), astrictObject:outcomez.enum(['completed', 'refused']).default('completed')completed(the default) is the ordinary terminal.refusedis a first-class refusal: the run recordsrefused— distinct fromfailed, a refusal is a successful evaluation that says no — carries the renderedmessage, is never resumed, and a runner shows the message with Close only: no Submit, no completion toast."messagez.string().min(1).optional(){token}template interpolated at run time exactly like a screendescription({record.name}etc.), so the text names the record. Required whenoutcomeisrefused; refused when it iscompleted— a completion renders nothing, so the key would be a silent no-op."A
superRefinekeeps the pair honest in both directions:outcome: 'refused'with nomessage⇒customissue at['message']("outcome: 'refused'requires amessage— a refusal with no text is the shape this contract exists to replace …");messagewithoutcome: 'completed'or omitted ⇒customat['message']("messageis only rendered whenoutcomeis 'refused' … silent no-op …"). Aliasesreason/text/description→message(the screen-node spelling a refusal migrates OUT of),status/result→outcome;titlecarries guidance ("Anendnode has no heading"). The history line names the before-state: "Until this shape was declared, anendnode had no config contract at all — any key was accepted at parse and ignored at run time, so a refusal an author wrote here shipped as a plain completion." Same TSDoc / describe wording asScreenConfig.descriptionfor the template — no second dialect, no.metamarker (screen'sdescriptioncarries none either).Where it is applied —
FlowNodeSchema(flow.zod.ts).endis structural (FLOW_STRUCTURAL_NODE_TYPES;service-automation/src/engine.ts:7345returns before any executor), so neither door every other builtin's config passes through exists for it — no descriptorconfigSchemaatregisterFlow(), no execute-timeparse(). The flow parse is its only door, so the node transform now runsparseEndNodeConfigafterparseFlowNodeRegions: atype: 'end'node'sconfigis parsed againstEndConfigSchema, issues re-raised under['config', …](a flow-level parse reportsnodes[i].config.message), and the parsed (defaulted) config written back. A node with noconfigis left without one. Every other type'sconfigstays the open, executor-owned slot (ADR-0018) — pinned.Run row (
execution.zod.ts):ExecutionStatusgains'refused'(appended LAST so every.optionsindex reader keeps its positions);ExecutionLogSchemagainsrefusalMessage?: string— "Renderedendnodemessagewhenstatusisrefused— the per-record text the flow refused with. Absent on every other status."Result / wire (
contracts/automation-service.ts,api/automation-api.zod.ts):AutomationResult.statusandTriggerFlowResponseSchema.data.statusgain'refused'; both gainrefusalMessage?: stringbesidesuccessMessage/errorMessage. On a refusalsuccessistrue(the evaluation succeeded),successMessageis absent (nothing to toast). The status pin (contracts/automation-result-status.pin.test.ts) now holds five members on both sides; the compile-timeEqbinding ofTriggerFlowResponse['data']toAutomationResultholds.Before / after — the card-shape probe (
FlowSchema.safeParse,{ id: 'end', type: 'end', label: 'End', config })c99449ab5){ outcome: 'refused', message: 'No — {record.name} is a duplicate' }{ outcome: 'refused' }customat['nodes', 1, 'config', 'message']{ outcome: 'refused', message: '' }too_smallat['nodes', 1, 'config', 'message']{ outcome: 'completed', message: 'silent' }/{ message: 'silent' }customat['nodes', 1, 'config', 'message']{ outcome: 'bogus' }invalid_valueat['nodes', 1, 'config', 'outcome']{ outcome: 'refused', message: 'x', reason: 'y' }unrecognized_keysat['nodes', 1, 'config'], "Did you meanreason→message?"{}{}{ outcome: 'completed' }ExecutionStatus.safeParse('refused')falsetrueformatZodErrorrenders the refusal as✗ nodes.1.config.message: \outcome: 'refused'` requires a `message` …. Measured boundary: a region-nestedend(inside aloopbody) is NOT refused by the flow parse alone —parseFlowNodeRegionsdeliberately leaves a region it cannot parse untouched (the registration walk owns nested diagnostics, #4389) — and IS refused byvalidateControlFlow, which re-parses the region throughFlowNodeSchemaand throws namingloop 'each' body` and the same sentence. Pinned as measured.PM mechanism assumptions — verified
FlowNodeSchema.configis an openz.record(ADR-0018) andb91c351e5(fix(spec): send a top-level flowtriggerto the START node config, not to atyperename #14735) added flow-level guidance sendingtriggerto the start node, not a start-node config schema (nothing namedStartConfigexists in spec; the start config is read structurally byflow-trigger-kind.tsand the engine). The precedent mirrored isScreenConfigSchema(astrictObjectin the same module); the wiring is the node transform, becauseendhas no executor door. The before-state probe is above.ExecutionStatusis the run-row vocabulary; the runner reads the RESULT vocabulary (AutomationResult.statusmirrored onTriggerFlowResponseSchema.data.status— objectuiflowResponse.ts:108branches on it). Both widened here; both inpackages/spec. The run record carries NO run-level error/message key today (a failure's reason issteps[].error;successMessage/errorMessagelive on the RESULT, copied from the flow definition), so there was no sibling to sit beside —refusalMessageis the new key on both the run row and the result (shape choice recorded in the report'sopen_questions).flow.form.tscarries only the flow-level sections (nodesis a repeater); no per-node form lives in spec. The screen node'sdescriptionrow is the descriptorconfigSchemainservice-automation/src/builtin/screen-nodes.ts:64, and objectui builds inspectors from descriptors (inspectors/flow-node-config.ts,json-schema-to-fields.ts) with'end'in its hand-writtenFLOW_NODE_TYPE_OPTIONS(:1203). The designer row forendis therefore lane 2's (anenddescriptor withconfigSchemareconciled bybuiltin-node-form-zod-ledger.test.ts— add{ nodeType: 'end', zod: EndConfigSchema }there) or lane 3's hand-written inspector. The JSON-schema row lane 2 copies isz.toJSONSchema(EndConfigSchema)—outcomeenum withdefault: 'completed',messagestring, the describe texts above.liveness/flow.jsoncarriesnodes.children.configas ONEliverow (the open record); noscreen.description/waitForInputrow exists.EndConfigSchemais not a registered metadata type, so the walker never reachesoutcome/message— no rows to add,check:livenessgreen unchanged. The honest not-yet-honoured status is stated where an author reads it: theEndConfigSchemaTSDoc, the docs callout ("Declared first — the engine and runner halves follow"), and the changeset.outcomedescribe, theAutomationResult.statusJSDoc and the docs; nothing about resumption pinned here.sys_automation_runis not a spec object (no.object.ts; it is the durable storeservice-automation/src/index.ts:104registers) — lane 2's.Reader census
ExecutionStatus/ result-status reader census (origin/mainc99449ab5, re-located by symbol; the list #15788 consumes)Readers that widen automatically (read
.optionsor the enum itself — nothing to change):packages/spec/src/api/automation-api.zod.ts:422—ListRunsRequestSchema.status: ExecutionStatus.optional()packages/spec/src/automation/execution.zod.ts:464—ScheduleStateSchema.lastRunStatuspackages/runtime/src/domains/automation.ts:1822—parseEnumParam('status', …, ExecutionStatus.options); the comments at:1799/:1810say "8 members" — prose onlypackages/runtime/src/query-param.ts:113,180— comments naming "the eightExecutionStatusmembers" — prose onlypackages/client/src/index.ts:157,4256,6560— type-only re-export /listRunsoption typepackages/spec/src/contracts/automation-service.ts:555—listRunsoption typepackages/services/service-automation/src/engine.ts:6,3660— import +listRunsoption typeReaders that must LEARN
refused(lane 2 = #15788, or its consumers):packages/services/service-automation/src/engine.ts:7345—if (node.type === 'end') return;— theendhandling that has to readEndConfigSchema, interpolatemessage, stampstatus: 'refused'+refusalMessageon the run row and the result (success: true, nosuccessMessage); terminal-status writes today at:4430,:4533,:5522,:5781,:8441(status: 'failed'), thecompletedcounterparts beside thempackages/services/service-automation/src/engine.ts:1333,5561,6293— comments asserting "ExecutionStatus… is unchanged / carries no such member" (stranded ruling prose) — still true forstranded; re-read when stampingrefusedpackages/plugins/plugin-approvals/src/approval-service.ts:262-271—classifyStrandedRunStatedocuments "the engine writes exactly four run statuses (paused,completed,failed,cancelled)"; itsdefaultarm stays SILENT on an unknown status, so arefusedrun is not misreported as stranded — but the release-on-terminal path pinned byapproval-service.test.ts:2577(it.each(['completed', 'cancelled', 'timed_out'])) does not listrefused: an approval request whose run endsrefusedmust be released too (decide in lane 2 / approvals)packages/plugins/plugin-approvals/src/stranded-request-inspection.test.ts:340-347— pins that an unrecognised status is NOT a strand (usestimed_out);refusedinherits that behaviourpackages/runtime/src/domains/automation-runs-query-validation.test.ts:320-322—it.each([8 members])('forwards every declared ExecutionStatus member')— a hand-copied list; stays green with 9 members but no longer coversrefused(extend in lane 2 or with the runtime half)packages/rest/src/rest-server.ts:11983— readsstatus === 'stranded'for the repairable envelope (result vocabulary);refusedneeds no arm there (success: true, 200)packages/app-shell/src/utils/flowResponse.ts:108,228-245—status?: 'completed' | 'paused' | 'failed' | string; today arefusedenvelope falls through tokind: 'done'(the completed toast) — lane 3 (objectui#7707) addskind: 'refused'readingrefusalMessage;FlowRunner.tsx:186-189is the toast site,:295,304-316theterminalClose-only footer the refusal reusesMirrors of the RESULT status vocabulary (all widened here, in spec):
AutomationResult.status(contracts/automation-service.ts:350),TriggerFlowResponseSchema.data.status(api/automation-api.zod.ts:343), the pincontracts/automation-result-status.pin.test.ts(five members), the compile-timeEqbinding inapi/automation-api.zod.test.ts:52(holds withrefusalMessageon both sides).Docs
content/docs/automation/flows.mdx: theendrow in the Node Types table; a new### Ending a run — \completed` or `refused`section (#end-node-outcome) with the example, the semantics in the ruling's words, the interpolation note, the Close-only runner behaviour stated as the contract, and a callout naming #15788 / objectui#7707 as where the halves land; the Observing-runs paragraph now listsrefusedamong the mirrored terminal statuses.approvals.mdx/workflows.mdxmeasured: neither enumerates terminal run statuses in a way this change falsifies (approvals' "terminal without a decision" list is about the stranded sweep; the approvals reader question is in the census) — untouched. Generated references (builtin-node-config.mdx,execution.mdx,api/automation-api.mdx,index.mdx) regenerated bygen:docs.content/docs/permissions/system-context.mdx:196re-anchored bycheck-system-context-census --fix(pure line rot from the lines added aboveflow.zod.ts:702`).Changeset
.changeset/flow-end-node-refused-outcome.md—@objectstack/specminor. Additive: nothing renamed or retired, ADR-0087 dispositionnot-required, nomigrations/registry.tsedit.Pins (spec)
packages/spec/src/automation/end-node-outcome.test.ts(new, 29 cases) —EndConfigSchemadefault / card shape / refused-without-message (code + path + message substance) / empty message / message-on-completed (explicit and omitted outcome) / bogus outcome / strict unknown key with suggestion /descriptionandstatusaliases /titleguidance;FlowSchemacard-shape probe with write-back / default write-back / no-config left alone / the same refusals at['nodes', i, 'config', …]/formatZodErrorrendering / plugin-node config stays open /defineFlowround-trip and refusal / region-nestedendat thevalidateControlFlowdoor; run row:refusedbesidefailed,refusalMessagepreserved and optional.execution.test.ts:refusedin the valid set, appended last,rejectedstill refused.automation-result-status.pin.test.ts: five members, a refused envelope PRESERVED on the wire (success: true,refusalMessage, nosuccessMessage), the JSDoc names the condition. Existingflow.test.tspins (including #15716'sedges[]block) green.Ablation
Script: a
trap restore EXIT INT TERMshell with absolute paths (REPO_ROOTfromgit rev-parse --show-toplevel), run under the verify lock on HEAD76106f88e. Resolution path asserted first:end-node-outcome.test.ts:22imports./builtin-node-config.zodrelatively fromsrc, andpackages/spec/vitest.config.tsdeclares no alias (0 matches) — the suite reads source, so no build leg applies. Mutation: theEndConfigSchemasuperRefinebody replaced by a no-op carrying anABLATION_14945_NOOPmarker; landing proven by anchor / marker counts (if (config.outcome === 'refused') {1 → 0, marker 0 → 1), worktree blob54fc9d4c…. Run overend-node-outcome.test.ts+flow.test.ts+builtin-node-config.test.ts: 9 failed / 163 passed — exactly the nine pins that assert the refused⇔message pairing (three onEndConfigSchemadirectly, four onFlowSchemaincl. theformatZodErrorline, thedefineFlowrefusal, the region-door case); every unknown-key, enum, default, write-back and existing pin stayed green (direction: red, as expected). Restore:git checkout HEAD -- ABS_PATH, proven bygit hash-object==git rev-parse HEAD:PATH(c8f4539e9ca6f647200ab350211bd564fb0f9ba2both) andgit diff HEADempty;git status --porcelain0 lines after.Verification
Base
c99449ab5(worktree fromorigin/main08:02Z);origin/mainmerged at8e7653b35viascripts/pm/os-regen-merge.sh(merge7126641be, regeneration commit76106f88e— the three os-regen paths staged from main's side were regenerated on the merged tree and the STAGED content checked by quoted-exact-name before committing; the six spec files sibling PRs #15804 / #15798 / #15792 / #15786 / #15782 / #15707 touched are byte-identical between this HEAD andorigin/main). Every build/test below ran throughscripts/pm/os-verify-lock.shand is quoted from itsVERDICT command-exitline; exit codes captured before any pipe.@objectstack/spec(full) —pnpm --filter @objectstack/spec buildgreen;check:generated: "All 15 generated artifacts are up to date" (after--fixregenerated the five it proved stale: api-surface, export-origins, declaration-map, docs, strictness-ledger);vitest runon HEAD76106f88e: 475 files / 12,782 tests passed (VERDICT command-exit 0, held 461s);typecheck(tsc +check:scripts-typecheck+check:test-typecheck) green via turbo.Consumer readings (downstream direction,
...@objectstack/spec; closures built cold first —core^...,service-automation^...,client^...,lint^...,runtime^...,rest^...,cli^...;turbo ls --affectedfrom$BASElists 75 packages, narrowed to the seven the dispatch names — declared narrowing, the rest is CI's):turbo run typecheckfor spec + service-automation + runtime + rest + client + lint + cli + core: 64 tasks successful (VERDICT command-exit 0) atb9ea7d41f.service-automationvitest run: 109 files / 1,303 tests passed atb9ea7d41f.client33 / 437,core49 / 1,190,lint97 / 3,327 passed atb9ea7d41f.runtimevitest run: 226 / 3,243 atb9ea7d41f; re-run on the merged head76106f88e: 227 / 3,256 passed.restvitest run: 180 / 3,086 passed atb9ea7d41f.clivitest run --project unit: 175 / 176 files at first pass — the one red,test/published-subpath-hook-body.pin.test.ts, was PREREQUISITE NOT MET (packages/cli is not built); afterturbo run build --filter=@objectstack/cliit passed (1 file / 13 tests) — so 176 files / 2,371 tests green; theintegrationlayer is declared to CI (the diff touches no spawn entry).The named-consumer suites were measured at
b9ea7d41f; the merge that followed touched none of the spec files this PR edits (only sibling changesets and unrelated packages), and the ratchet/gate families were re-run on the final head (below).Gate readings —
node scripts/pm/dispatch-gates.mjs(no paths) derived 94 owed families (82 path-matched + 5 kind + 7 whole-tree) atfe4ba76f4; harvested with--commands. Run on the final head76106f88ewith per-command exit codes: 94 commands run, 87 exit 0 on the first pass, 7 exit 1 — all 7 the samedist/**/*.d.ts is OLDER than packages/spec/srcfreshness guard (check:api-surface,check:browser-reachable-entries,check:dual-source-exports,check:entry-nameability,check:exported-any,check:generated,check:skill-examples), tripped by the ablation's byte-identical restore bumping the source mtime after the last build (dist content was current:EndConfigSchemapresent inautomation/index.d.ts, no ablation marker); rebuilt@objectstack/specunder the lock (no tree change, porcelain 0) and re-ran those seven: all exit 0. 2 families deferred as build-heavy / whole-workspace prerequisites (check:dual-build-cjs-loads,check:type-check-debt; theturbo/vitestentries are the suites reported above). Two real findings during the run, both fixed in-branch:check:doc-anchors(a bracket anchor on a bold paragraph renders no heading id — the section is now a real###heading) andcheck-system-context-census(pure line rot from the lines added aboveflow.zod.ts:702—--fixre-anchoredsystem-context.mdx). Additional spec audits run individually and green:check:variant-docs(not a discriminated union; 18 unions, 8 governed),check:liveness,check:strictness-ledger,check:exported-any,check:dual-source-exports,check:entry-nameability,check:browser-reachable-entries,check:empty-state,check:llms-txt,check:yaml-examples,check:error-code-provenance,check:meta-url-spelling,check:spec-changes,check:react-blocks,check:skill-examples(after buildingclient-react: 257 prose examples type-check),check:cross-package-test-inputs,check:type-check-coverage. NOT MEASURED locally (prerequisite: eleven packages unbuilt — a whole-workspace build):check:dual-build-cjs-loads,check:type-check-debt --re-measure— CI's.pnpm lint(repo-wide eslint) not run locally — CI's.Open questions (for contract review)
refusalMessage?: stringon the run row (ExecutionLogSchema) and on the result (AutomationResult/TriggerFlowResponse.data), mirroring the flatsuccessMessage/errorMessageidiom. Alternative:refusal: { nodeId, message }(names WHICHendrefused when a flow has several). Four-axis reasoning in the report; the reviewer can veto cheaply before service-automation: honouroutcome: 'refused'on the flowendnode — a terminalrefusedrun status (distinct fromfailed) with the interpolated message persisted on the run (lane 2 of the #14945 ruling 2′) #15788 starts.successon a refused result — chosentrue(the ruling: "a successful evaluation that says no"); a runner keys onstatus, never onsuccess, for the Close-only rendering.Generated by Claude Code